Islands 架构原理和实践
Islands 架构是今年比较火的一个话题,目前社区一些比较知名的新框架如 Fresh、Astro 都是 Islands 架构的典型代表。本文将给大家介绍 Islands 架构诞生的来龙去脉,分析它相比于 Next.js、Gatsby 等传统方案的优势,并且剖析社区相关框架的实现原理,以及分享笔者在这个方向上的一些实践。
MPA 和 SPA 的取舍
MPA 和 SPA 是构建前端页面常见的两种方式,理解 MPA 和 SPA 的区别和不同场景的取舍是理解 Islands 架构的关键。
概念
MPA(Multi-page application) 即多页应用,是从服务器加载多个 HTML 页面的应用程序。每个页面都彼此独立,有自己的 URL。当单击 a 标签链接导航到另一个页面时,浏览器将向服务器发送请求并加载新页面。例如,传统的模板技术如JSP、Python、Django、PHP、Laravel 等都是基于 MPA 的框架,包括目前比较火的 Astro 也是采用的 MPA 方案。
SPA(Single-page application) 即单页应用,它只有一个不包含具体页面内容的 HTML,当浏览器拿到这份 HTML 之后,会请求页面所需的 JavaScript 代码,通过执行 JavaScript 代码完成 DOM 树的构建和 DOM 的事件绑定,从而让页面可以交互。如现在使用的大多数 Vue、React 中后台应用都是 SPA 应用。
对比
1. 性能
在 MPA 中,服务器将响应完整的 HTML 页面给浏览器,但是 SPA 需要先请求客户端的 JS Bundle 然后执行 JS 以渲染页面。因此,MPA 中的页面的首屏加载性能比 SPA 更好。
2. SEO
3. 路由
4. 状态管理
取舍
总而言之,MPA 有更好的首屏性能,SPA 在后续页面的访问中有更好的性能和体验,但 SPA 也带来了更高的工程复杂度、略差的首屏性能和 SEO。这样就需要在不同的场景中做一些取舍。
不过,MPA 和 SPA 也并不是完全割裂的,两者也是能够有所结合的,比如 SSR/SSG 同构方案就是一个典型的体现,首先框架侧会在服务端生成完整的 HTML 内容,并且同时注入客户端所需要的 SPA 脚本。这样浏览器会拿到完整的 HTML 内容,然后执行客户端的脚本事件的绑定(这个过程也叫 hydrate
),后续路由的跳转由 JS 来掌管。当下很多的框架都是采用这样的方案,比如 Next.js、Gatsby、公司内部的 Eden SSR、Modern.js。
但实际上,把 MPA 和 SPA 结合的方案也并不是完美无缺的,主要的问题在于这类方案仍然会下载全量的客户端 JS
及执行全量的组件 Hydrate 过程
,造成页面的首屏 TTI 劣化。
我们可以试想对于一个文档类型的站点,其实里面的大多数组件是不需要交互的,主要以静态页面的渲染为主,因此直接采用 MPA 方案是一个比 MPA + SPA
更好的一个选择。进一步讲,对于更多的轻交互、重内容的应用场景,MPA 也依然是一个更好的方案。
由于页面中有时仍然不可避免的需要一些交互的逻辑,那放在 MPA 中如何来完成呢?这就是 Islands 架构所要解决的问题。
什么是 Islands 架构?
Json Miller
在 Islnads Architecture 一文中得到推广。这个模型主要用于 SSR (也包括 SSG) 应用,我们知道,在传统的 SSR 应用中,服务端会给浏览器响应完整的 HTML 内容,并在 HTML 中注入一段完整的 JS 脚本用于完成事件的绑定,也就是完成 hydration (注水) 的过程。当注水的过程完成之后,页面也才能真正地能够进行交互。Islands 实现原理
Astro
https://astro.build/
在 Astro 中,默认所有的组件都是静态组件,比如:
// index.astro
import MyReactComponent from '../components/MyReactComponent.jsx';
---
<MyReactComponent />
激活孤岛组件
了,在使用组件时加上client:load
指令即可:// index.astro
---
import MyReactComponent from '../components/MyReactComponent.jsx';
---
<MyReactComponent client:load />
Astro 除了支持本身 Astro 语法之外,也支持 Vue、React 等框架,可以通过插件的方式来导入。在构建的时候,Astro 只会打包并注入 Islands 组件的代码,并且在浏览器渲染,分别调用不同框架(Vue、React)的渲染函数完成各个 Islands 组件的 hydrate 过程。
Astro 是典型的 MPA 方案,不支持引入 SPA 的路由和状态管理。
Fresh
islands
目录专门存放 island 组件:.
├── README.md
├── components
│ └── Button.tsx
├── deno.json
├── dev.ts
├── fresh.gen.ts
├── import_map.json
├── islands // Islands 组件目录
│ └── Counter.tsx
├── main.ts
├── routes
│ ├── [name].tsx
│ ├── api
│ │ └── joke.ts
│ └── index.tsx
├── static
│ ├── favicon.ico
│ └── logo.svg
└── utils
└── twind.ts
通过扫描 islands 目录记录项目中声明的所有 Islands 组件。 拦截 Preact 中 vnode 的创建逻辑,目的是为了匹配之前记录的 Island 组件,如果能匹配上,则记录 Island 组件的 props 信息,并将组件用 的注释标签来包裹,id 值为 Island 的 id,数字为该 Island 的 props 在全局 props 列表中的位置,方便 hydrate 的时候能够找到对应组件的 props。 调用 Preact 的 renderToString 方法将组件渲染为 HTML 字符串。 向 HTML 中注入客户端 hydrate 的逻辑。 拼接完整的 HTML,返回给前端。
更多细节可以参考篇文章:深入解读 Fresh
实践分享
仓库: https://github.com/sanyuan0704/island.js
使用 Island 组件
__island
标志即可,比如:import { Aside } from '../components/Aside';
export function Layout() {
return <Aside __island />;
}
内部实现
1. SSR Runtime
// island-jsx-runtime.js
import * as jsxRuntime from 'react/jsx-runtime';
export const data = {
// 存放 islands 组件的 props
islandProps: [],
// 存放 islands 组件的文件路径
islandToPathMap: {}
};
const originJsx = jsxRuntime.jsx;
const originJsxs = jsxRuntime.jsxs;
const internalJsx = (jsx, type, props, ...args) => {
if (props && props.__island) {
data.islandProps.push(props || {});
const id = type.name;
// __island 的 prop 将在 SSR 构建阶段转换为 `__island: 文件路径`
data.islandToPathMap[id] = props.__island;
delete props.__island;
return jsx('div', {
__island: `${id}:${data.islandProps.length - 1}`,
children: jsx(type, props, ...args)
});
}
return jsx(type, props, ...args);
};
export const jsx = (...args) => internalJsx(originJsx, ...args);
export const jsxs = (...args) => internalJsx(originJsxs, ...args);
export const Fragment = jsxRuntime.Fragment;
2. Build Time
SSR bundle: 用于 renderToString Client bundle: 客户端 Runtime 代码,用于激活页面
__island
prop,它实际上是为了标识该组件是一个 Islands 组件,但我们拿不到组件的路径信息。为了之后能够顺利打包 Islands 组件,我们需要在 SSR 构建过程中将 __isalnd 进行转换,使之带上路径信息。比如下面有两个组件:// Layout.tsx
import { Aside } from './Aside.tsx';
export function Layout() {
return (
<div>
<Aside __island a={1} />
</div>
)
}
// Aside.tsx
export function Aside() {
return <div>内容省略...</div>
}
<Aside __island />
的方式来使用 Aside 组件,标识其为一个 Islands 组件。那么我们将会在 SSR 编译过程中用 babel 插件改写这个 prop,原理如下:<Aside __island />
// 被转换为
<Aside __island="./Aside.tsx!!island!!Users/project/src/Layout.tsx" />
{
islandProps: [ { a: 1 } ],
islandToPathMap: {
Aside: './Aside.tsx!!island!!Users/project/src/Layout.tsx'
}
}
将 islandProps 的数据作为 id 为 island-props
的 script 标签注入到 HTML 中;根据 islandToPathMap 的信息构造虚拟模块,打包所有的 Islands 组件。
import { Aside } from './Aside.tsx!!island!!Users/project/src/Layout.tsx';
window.islands = {
Aside
};
window.ISLAND_PROPS = JSON.parse(
document.getElementById('island-props').textContent
);
将这个虚拟模块打包后我们得到一份 Islands bundle,将这个 bundle 注入到 HTML 中以完成 Islands 组件的注册。
💡 问题: islands bundle 和 client bundle 有共同的依赖 React,由于在两次不同的打包流程中,所以 React 会打包两份。解决方案是 external 掉 react 和 react-dom 依赖,通过 import map 指向全局唯一的 React 实例。
3. Client Runtime
import { hydrateRoot, createRoot } from 'react-dom/client';
const islands = document.querySelectorAll('[__island]');
for (let i = 0; i < islands.length; i++) {
const island = islands[i];
const [id, index] = island.getAttribute('__island')!.split(':');
const Element = window.ISLANDS[id];
hydrateRoot(
island,
<Element {...window.ISLAND_PROPS[index]}></Element>
);
}
HTTP 请求资源(KB) | FCP (s) | DCL(s) | |
---|---|---|---|
SPA 模式 | 451 | 0.48 | 0.84 |
Islands 模式 | 141 | 0.40 | 0.52 |
优化情况 | 减少近 60% | 变化不大 | 提前近 40% |
Islands 架构的适用性
1. 框架无关
Island 架构的实现其实是可以做到框架无关的。从 SSR Runtime、Build Time 到 Client Runtime,整个环节中关于 React 的部分,我们都可以替换成其它框架的实现,这些部分包括:
创建组件的方法
组件转换为 HTML 字符串的 renderToString 方法
浏览器端的 hydrate 方法
因此,不光是 React,对于 Vue、Preact、Solidjs 这些框架中都可以实现 Islands 架构。因此,在 Island.js 中兼容除 React 的其它框架也是原理上完全可行的。
并且考虑到 React 的包体积问题,后续 Island.js 考虑适配其它的框架,如 Solid,体积相比 React 可以减少 90%:
数据来源: https://dev.to/this-is-learning/javascript-framework-todomvc-size-comparison-504f
2. VitePress 的特殊优化
VitePress 会在 hydrate 的过程中把正文的静态部分
排除,具体实现原理如下:
Vue 模板编译阶段,Vue 会对静态虚拟 DOM 节点进行优化,输出
createStaticVNode
的格式
在 Chunk 生成阶段(实现链接),把内容部分用 __VP_STATIC_START__
和__VP_STATIC_END__
标志位包裹在生成打包产物前,针对每个页面打包出两份 JS 一份是包含完整内容的 JS,把标志位去掉即可,比如文件名为 recommend.[hash].js
另一份是不包含内容的 JS,把标志位及其里面的内容删掉,文件名为 recommend.[hash].lean.js
首屏使用 .lean.js
,不包含正文部分的 JS,实现Partial Client Bundle
+Partial Hydration
,跟 Islands 架构一样的效果二次页面跳转使用完整的 JS,因为走 SPA 路由跳转,需要拿到完整的页面内容,用 JS 渲染出来。
.lean.js
里面,组件的代码都被改了,难道 Vue 在 hydrate 不会发现内容和服务端渲染的 HTML 对应不上进而报错吗?答案是不会,我们可以看看 Vue 里面 createStaticVNode 的实现:staticCount
即节点数量而不是内容,那么对于如下的 VNode 节点来讲 hydrate 仍然是可以成功的:// recommend.[hash].lean.js
const html = ` A <span>foo</span> B`
const { vnode, container } = mountWithHydration(html, () =>
// 保证第二个参数正确即可
createStaticVNode(``, 3)
)
总之, VitePress 利用 Vue 的编译时优化以及内部定制的 Hydrate 方案足以解决传统 SSG 的全量 hydration 问题,采用 Islands 架构意义并不大。
那进一步讲,像 Vue 这种 Shell 优化方案对于包含编译时的前端框架是否通用?这里我们可以先大概总结出 Shell 方案需要满足的条件:
模板编译阶段,将静态节点进行特殊标记
运行时,支持 hydrate 跳过对静态节点的内容检查
基于上面这两点,其他的代表性编译时框架如Solid
、Svelte
很难实现 Vue 的 Shell 架构(没法标记静态节点),因此 Shell 方案可以理解为在 Vue 框架下的一个特殊优化了。对于 Vue 外的其它框架方案,仍然可以采用 Islands 进行特定场景的优化。